2025-08-17 객체지향 정글 스터디 ch03, ch04
TIL
[137]
테스트에 필요한 간접 입력 값을 제공하기 위해 스텁(stub)을 추가하거나 간접 출력 값을 검증하기 위해 목(mock) 객체를 사용하는 것은 객체와 협력해야 하는 협력자에 관해 고민한 결과를 코드로 표현한 것이다.
- STUB: 가짜입력
- MOCK: 가짜출력
책임주도설계 (Responsibility Driven Design)
어느 객체도 섬이 아니라는 말은, 객체들끼리 긴밀하게 협업할 정도로 협조적이어야 한다는 것이며, 객체간 요청/응답 플로우에 얼마든지 다른 객체가 그 역할을 도맡을 수도 있다는 것을 의미한다.
책에서 책임은 두가지 종류가 있다고 이야기한다:
- 하는 것의 책임
- 아는 것의 책임
먼저 하는 것은 우리가 가장 쉽게 접하는 종류의 책임이다. 대표적으로 "계산을 하라", "특정 로직을 실행하라", "상태를 바꾸어라"와 같은 것들은 본인의 상태 혹은 다른 객체의 상태를 변경하게 만들거나 다른 객체에게 명령을 전달하는 등의 책임을 의미한다.
아는 것의 책임은 우리가 실수로 만들어버리는 거대한 섬 객체를 생각해보면 쉽게 떠올릴 수 있을 것 같다. 섬 객체는 모든 것을 알고 있다. 섬 안의 주민들의 이름, 주민번호. 도로 표지판의 위치와 신호 변경 규칙. 언제 해가 뜨고 언제 해가 지는지 등을 모두 알고 모든 것을 조율한다. 우리는 지금까지 "알고있다"의 범위를 "가지고 있다"로 착각하고 있었다. 사실 객체는 협력을 통해서 특정 주제를 알고 있는 객체에게 대신해서 물어볼 수도 있는데 말이다, 마치 증인을 불러 세우는 판사는 굳이 증인의 인상착의와 이름을 알 필요 없는 것처럼.
책임주도설계 스니펫, 상태머신 예시
- 유한개의 상태 (‼️ 정수 혹은 실수 같이 무한한 가짓수의 상태는 유한상태라고 부를 수 없음)
- 초기상태
- 상태전이 유향그래프 (이전 상태 → 이후 상태)
- 각 상태전이를 일으키는 이벤트
stateDiagram-v2 [*] --> PLANNING state "PENDING_PAYMENTS" as PENDING state "PLANNING" as PLANNING state "CONFIRMED" as CONF state "EXPIRED" as EXP state "CANCELED" as CANC %% 기본 전이 PLANNING --> PENDING: inviteSent PENDING --> CONF: IF allPaid? THEN confirmReservation %% 내부 이벤트(상태 유지 or 전이 트리거) PENDING --> PENDING: paymentSucceeded %% 타임아웃/취소 PENDING --> EXP: IF now >= expiresAt THEN releaseHold PENDING --> CANC: IF cancelByInitiator THEN releaseHold %% 종료 상태 CONF --> [*] CANC --> [*] EXP --> [*]
판사(aggregate)는 사건 기록(스냅샷)을 보유하고 있으며, 각 증인(state)을 불러서 “현재 상태에서 이 이벤트를 어떻게 처리해야 하나?”를 묻는다. 증인은 자신의 역할을 수행하고 나서 다음에 누구를 불러야 할지(stateFactory를 통해 새 상태를 반환) 결정한다. Aggregate는 모든 증언이 끝난 뒤 불변식(‘지불 금액이 음수가 될 수 없다’, ‘전체 금액을 초과하여 결제할 수 없다’)을 검증하고, 부수효과는 외부 세계로 내보내기만 한다.
협력관계 그래프 (클래스 다이어그램)
classDiagram class PaymentSessionState { +on() } class Event { +type +payload } class PlanningState { +on() } class PendingPaymentsState { +on() } class PaymentSessionAggregate { +apply() } %% 집합/합성 관계 (Aggregate가 상태/스냅샷/이벤트를 소유) PaymentSessionAggregate *-- PaymentSessionState : has %% 상속 PlanningState --|> PaymentSessionState PendingPaymentsState --|> PaymentSessionState %% 협력 관계 PaymentSessionState --> Event : handles
스니펫 코드 (TypeScript)
interface Event {
type: string;
payload: any;
}
/**
* 상태 값들을 추상화해놓은 값 객체, 불변
*/
interface PaymentSessionSnapshot {
id: string;
state: string;
totalAmount: number;
paidAmount: number;
expiresAt: Date;
}
/**
* 상태 전이 결과.
* - next: 다음 스냅샷(상태 값)
* - state: 다음 상태 객체
* - commands: 외부에서 수행해야 할 명령들의 이름
*/
interface TransitionResult {
next: PaymentSessionSnapshot;
state: PaymentSessionState;
commands: string[];
}
abstract class PaymentSessionState {
abstract readonly name: string;
abstract on(s: PaymentSessionSnapshot, e: Event): TransitionResult;
/** 처리 불가 이벤트에 대한 기본 거부 처리 */
protected reject(s: PaymentSessionSnapshot): TransitionResult {
return { next: s, state: this, commands: ['REJECT'] };
}
}
class PaymentSessionAggregate {
private state: PaymentSessionState;
private snapshot: PaymentSessionSnapshot;
private uncommitted: Event[] = [];
constructor(seed: PaymentSessionSnapshot) {
this.snapshot = seed;
this.state = stateFactory(seed.state);
}
apply(event: Event): string[] {
const { next, state, commands } = this.state.on(this.snapshot, event);
// 금액 불변식 점검
if (next.paidAmount < 0 || next.totalAmount < 0) {
throw new Error('amount invariant');
}
if (next.paidAmount > next.totalAmount) {
throw new Error('overpaid invariant');
}
this.snapshot = next;
this.state = state;
// 재생 모드에서는 명령을 무시
return mode === 'live' ? commands : [];
}
get uncommitEvents() {
return this.uncommitted;
}
}
/** PLANNING 상태: 초대 발송 후 결제 대기 상태로 전이 */
class PlanningState extends PaymentSessionState {
name = 'PLANNING';
on(s: PaymentSessionSnapshot, e: Event): TransitionResult {
if (e.type === 'InvitesSent') {
const next: PaymentSessionSnapshot = { ...s, state: 'PENDING_PAYMENTS' };
// 명령들은 단순 문자열 형태로만 전달
const commands = [
'CREATE_PROVISIONAL_RESERVATION',
'EMIT_OUTBOX',
'SCHEDULE_REMINDER',
];
return { next, state: stateFactory('PENDING_PAYMENTS'), commands };
}
return this.reject(s);
}
}
/** PENDING_PAYMENTS 상태: 결제 성공/만료/취소 이벤트 처리 */
class PendingPaymentsState extends PaymentSessionState {
name = 'PENDING_PAYMENTS';
on(s: PaymentSessionSnapshot, e: Event): TransitionResult {
if (e.type === 'PaymentSucceeded') {
const delta = Math.min(e.payload.amount, s.totalAmount - s.paidAmount);
const next = { ...s, paidAmount: s.paidAmount + delta };
const commands = ['EMIT_OUTBOX'];
if (next.paidAmount >= next.totalAmount) {
next.state = 'CONFIRMED';
commands.push('CONFIRM_RESERVATION', 'CANCEL_REMINDER', 'EMIT_OUTBOX');
return { next, state: stateFactory('CONFIRMED'), commands };
}
return { next, state: this, commands };
}
if (e.type === 'ExpireTimerFired') {
// 만료 시간이 지나지 않았다면 거부
if (new Date() < s.expiresAt) {
return this.reject(s);
}
const next: PaymentSessionSnapshot = { ...s, state: 'EXPIRED' };
const commands = [
'RELEASE_PROVISIONAL_RESERVATION',
'CANCEL_REMINDER',
'EMIT_OUTBOX',
];
return { next, state: stateFactory('EXPIRED'), commands };
}
if (e.type === 'CancelRequested') {
const next: PaymentSessionSnapshot = { ...s, state: 'CANCELED' };
const commands = [
'RELEASE_PROVISIONAL_RESERVATION',
'CANCEL_REMINDER',
'EMIT_OUTBOX',
];
return { next, state: stateFactory('CANCELED'), commands };
}
return this.reject(s);
}
}
/** 상태를 이름에서 생성하는 팩토리 */
function stateFactory(name: string): PaymentSessionState {
switch (name) {
case 'PLANNING':
return new PlanningState();
case 'PENDING_PAYMENTS':
return new PendingPaymentsState();
// …다른 상태 구현 생략
default:
throw new Error(`Unknown state: ${name}`);
}
}